iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 22

Kotlin 手刻 Ktor 從零開始 Day 22 Request Validation,輸入驗證機制

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

有了 receive<T>(),JSON 可以解析成 data class,但「型別正確」不代表「資料有效」

  • name 不能是空白
  • age 不能是負數
  • email 必須符合格式

這些規則如果寫在 handler 裡,每個 endpoint 都要重複一堆 if/else,這篇要做的是在框架層提供一套可組合、可測試的驗證 DSL,失敗時回 422 Unprocessable Entity 加結構化錯誤 JSON

目標 API

我們希望能這樣寫驗證規則

val userValidator = validate<RegisterRequest> {
    field(RegisterRequest::name) { notBlank(); maxLength(50) }
    field(RegisterRequest::age) { min(0); max(150) }
    field(RegisterRequest::email) { matches(emailRegex) }
}

然後在 handler 裡用一行搞定

post("/users") {
    val req = receive<RegisterRequest>()
    val result = userValidator.validate(req)
    if (!result.isValid) return@post unprocessableEntity(result)
    // ...
}

RegisterRequest::nameKProperty1<RegisterRequest, String>,它同時提供欄位名稱與從 request 取值的方式,req::name 則是綁定特定物件的 KProperty0,不符合這個 builder 需要的型別

驗證分成四個部分

驗證這一套跟框架既有的檔案沒有相依關係,全部都是新的,在動手之前先看一次全貌

  • Constraint,一條規則,收一個值,回 null 或錯誤訊息
  • FieldValidator,一個欄位要套用的全部規則,跑完收集錯誤
  • validate<T> { },把 property reference 跟規則組成一個 validator
  • unprocessableEntity(),把驗證結果變成 422 回應

由內而外做,一個部分一輪,每一輪都先寫測試再實作,程式碼會落在這幾個檔案

  • Validation.ktConstraintConstraintsValidationErrorValidationResultFieldValidatorValidator,是驗證真正在跑的那個部分
  • ValidationDsl.ktFieldValidatorBuilder 跟五個 extension function、ValidatorBuildervalidate<T> { },是給人寫規則的那個部分
  • ValidationResponse.ktValidationErrorResponseFieldErrorunprocessableEntity(),負責把驗證結果變成 HTTP 回應
  • 測試四個部分各一個檔案,ConstraintTest.ktFieldValidatorTest.ktValidationTest.ktValidationIntegrationTest.kt

TDD 先確認 Constraint 的行為

最裡面那個部分就是一條規則,收一個值,回 null 表示通過,回字串表示錯誤訊息,不需要 validator、不需要 DSL、更不用起 server,建好直接呼叫 check() 就測得到,測試檔案放 ConstraintTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")

class ConstraintTest {

    @Test
    fun `notBlank passes a non blank string`() {
        assertNull(Constraints.notBlank().check("Relix"))
    }

    @Test
    fun `notBlank rejects whitespace only`() {
        assertEquals("must not be blank", Constraints.notBlank().check("   "))
    }

    @Test
    fun `maxLength passes at the boundary`() {
        assertNull(Constraints.maxLength(5).check("Relix"))
    }

    @Test
    fun `maxLength rejects one char over`() {
        assertEquals("max length is 5", Constraints.maxLength(5).check("Relix!"))
    }

    @Test
    fun `min passes at the boundary`() {
        assertNull(Constraints.min(0).check(0))
    }

    @Test
    fun `min rejects a smaller value`() {
        assertEquals("must be >= 0", Constraints.min(0).check(-1))
    }

    @Test
    fun `max passes at the boundary`() {
        assertNull(Constraints.max(150).check(150))
    }

    @Test
    fun `max rejects a larger value`() {
        assertEquals("must be <= 150", Constraints.max(150).check(151))
    }

    @Test
    fun `matches passes a valid email`() {
        assertNull(Constraints.matches(emailRegex).check("relix@example.com"))
    }

    @Test
    fun `matches rejects and carries the pattern`() {
        assertEquals(
            "must match pattern ${emailRegex.pattern}",
            Constraints.matches(emailRegex).check("not-an-email"),
        )
    }
}

十個測試,五個規則各一組通過跟失敗,通過的那個測試都是在邊界上,maxLength(5) 餵五個字、min(0) 餵 0、max(150) 餵 150,邊界值該過就要過,差一個字元才開始錯

emailRegex 宣告在測試檔的 class 外面,後面三輪同一個 package 底下都拿得到,不用再寫一次

最後一個測試驗證的是錯誤訊息會把整個 regex 原封不動帶出來,這裡先照著寫,這件事到 main 那節還會再出現一次,因為它同時也是個設計問題

實作 Constraint 與五個內建規則

規則本身的型別很小,寫進 Validation.kt

fun interface Constraint<V> {
    fun check(value: V): String?
}

fun interface 是因為每個 constraint 就只有一個方法,可以用 lambda 建立

五個內建的 constraint 接在後面

object Constraints {
    fun notBlank(): Constraint<String> = Constraint { value ->
        if (value.isBlank()) "must not be blank" else null
    }

    fun maxLength(max: Int): Constraint<String> = Constraint { value ->
        if (value.length > max) "max length is $max" else null
    }

    fun min(min: Int): Constraint<Int> = Constraint { value ->
        if (value < min) "must be >= $min" else null
    }

    fun max(max: Int): Constraint<Int> = Constraint { value ->
        if (value > max) "must be <= $max" else null
    }

    fun matches(regex: Regex): Constraint<String> = Constraint { value ->
        if (!regex.matches(value)) "must match pattern ${regex.pattern}" else null
    }
}

每個 constraint 都是一個工廠函式,回傳 Constraint<V> 的實例,五個都集中在 Constraints 這個 object 底下,這麼做的理由留到後面「常見陷阱」那裡會講

error message 寫在 constraint 裡面,不是在 caller 端,這樣同一個 constraint 不管用在哪裡,訊息都一致,前面那十個測試到這裡就全部通過了

TDD 先確認 FieldValidator 收集錯誤的方式

再往外,一個欄位可能有好幾條規則,這個部分要回答的是「規則跑完之後,錯誤長什麼樣、有幾個、什麼順序」

這個部分還是不用 DSL,測試自己動手把 FieldValidator 組出來就好,測試檔案放 FieldValidatorTest.kt

import kotlinx.serialization.Serializable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

// @Serializable 是後面「TDD 寫 handler 整合測試」的 receive<T>() 要用的
@Serializable
data class RegisterRequest(val name: String, val age: Int, val email: String)

class FieldValidatorTest {

    private fun nameField(vararg constraints: Constraint<String>): FieldValidator<String> {
        val field = FieldValidator<String>("name") { (it as RegisterRequest).name }
        constraints.forEach { field.addConstraint(it) }
        return field
    }

    @Test
    fun `a passing constraint produces no error`() {
        val errors = nameField(Constraints.notBlank())
            .validate(RegisterRequest("Relix", 25, "relix@example.com"))

        assertTrue(errors.isEmpty())
    }

    @Test
    fun `a failing constraint carries the field name`() {
        val errors = nameField(Constraints.notBlank())
            .validate(RegisterRequest("   ", 25, "relix@example.com"))

        assertEquals(1, errors.size)
        assertEquals("name", errors[0].field)
        assertEquals("must not be blank", errors[0].message)
    }

    @Test
    fun `only the failing constraint is collected`() {
        val errors = nameField(Constraints.notBlank(), Constraints.maxLength(3))
            .validate(RegisterRequest("TooLong", 25, "relix@example.com"))

        assertEquals(1, errors.size)
        assertEquals("max length is 3", errors[0].message)
    }

    @Test
    fun `one field can produce multiple errors in declaration order`() {
        val errors = nameField(Constraints.notBlank(), Constraints.maxLength(3))
            .validate(RegisterRequest("     ", 25, "relix@example.com"))

        assertEquals(2, errors.size)
        assertTrue(errors.all { it.field == "name" })
        assertEquals("must not be blank", errors[0].message)
        assertEquals("max length is 3", errors[1].message)
    }

    @Test
    fun `empty error list means valid`() {
        assertTrue(ValidationResult(emptyList()).isValid)
    }

    @Test
    fun `any error means invalid`() {
        assertFalse(ValidationResult(listOf(ValidationError("name", "boom"))).isValid)
    }
}

nameField() 這個 helper 把「建一個綁 name 欄位的 validator,塞規則進去」包起來,四個測試差別只在塞什麼規則、餵什麼值

only the failing constraint is collectedone field can produce multiple errors in declaration order 是這輪的重點,前者送 "TooLong"notBlank() 過了 maxLength(3) 沒過,通過的那條不能留下任何痕跡,後者送 " ",兩條規則同時違反,兩個錯誤都掛在 name 這個欄位上,順序跟規則寫下去的順序一致

RegisterRequest 這輪第一次出現,因為 FieldValidator 要從一個物件身上取值,總得有個物件

實作 ValidationError、ValidationResult 與 FieldValidator

先是兩個裝資料的型別,補進 Validation.kt

class ValidationError(val field: String, val message: String)

class ValidationResult(val errors: List<ValidationError>) {
    val isValid: Boolean get() = errors.isEmpty()
}

ValidationError 帶欄位名稱和錯誤訊息,ValidationResult 包一個 errors list,isValid 是空 list 檢查的 shortcut

為什麼不用 data class ? 因為 ValidationError 不需要 equalscopy,測試時比對的是 .field.message 屬性,不是整個物件

也許你會想,這裡用 sealed class 不是更乾淨嗎

sealed class ValidationResult {
    data object Valid : ValidationResult()
    data class Invalid(val errors: List<ValidationError>) : ValidationResult()
}

對,when (result) 的分支會被 compiler 強制窮舉,Valid 直接是 singleton 不用配對 errors list,型別告訴你「不可能拿一個 isValid=true 但 errors 非空的物件」,這裡選了 list + flag 的扁平版只是因為 handler 裡的用法極度簡單 (if (!result.isValid) return ...),多一層 destructuring 其實是噪音,但只要你開始支援 nested validation (一個欄位的 errors 自己也是 ValidationResult)、或想在 type 層級表達「這個函式只接受已驗證過的 input」,sealed class 就回本了,這也是 framework code 跟 application code 在這類選擇上常常不同調的原因,重用次數越高,型別越嚴一點越值得

接著是這輪的主角,一樣補進 Validation.kt

class FieldValidator<V>(val fieldName: String, val getter: (Any) -> V) {
    private val constraints = mutableListOf<Constraint<V>>()

    fun addConstraint(constraint: Constraint<V>) {
        constraints.add(constraint)
    }

    fun validate(target: Any): List<ValidationError> {
        val value = getter(target)
        return constraints.mapNotNull { constraint ->
            constraint.check(value)?.let { message ->
                ValidationError(fieldName, message)
            }
        }
    }
}

FieldValidator 綁定一個欄位,getter 從物件取出欄位值,constraints 是這個欄位要套用的全部規則,validate() 把每個 constraint 跑一遍,收集錯誤,它是驗證真正在跑的地方

mapNotNull 把 null (通過) 過濾掉,只留有錯誤訊息的結果,這正好是上一節那兩個重點測試要的行為,通過的規則不留痕跡,失敗的規則一個都不少

TDD 先確認 DSL 組出來的 validator

規則怎麼跑已經測完了,這輪換規則怎麼寫,回到最前面那個目標 API

前兩輪的欄位名稱都是自己手寫的字串 "name",DSL 要做的是拿 RegisterRequest::name 換掉它,順便把好幾個欄位組成一個 validator,測試檔案放 ValidationTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class ValidationTest {

    private val validator = validate<RegisterRequest> {
        field(RegisterRequest::name) { notBlank(); maxLength(50) }
        field(RegisterRequest::age) { min(0); max(150) }
        field(RegisterRequest::email) { matches(emailRegex) }
    }

    @Test
    fun `valid input passes`() {
        val result = validator.validate(RegisterRequest("Relix", 25, "relix@example.com"))

        assertTrue(result.isValid)
        assertTrue(result.errors.isEmpty())
    }

    @Test
    fun `field name comes from the property reference`() {
        val result = validator.validate(RegisterRequest("  ", 25, "relix@example.com"))

        assertFalse(result.isValid)
        assertEquals(1, result.errors.size)
        assertEquals("name", result.errors[0].field)
    }

    @Test
    fun `an int field goes through its own constraints`() {
        val result = validator.validate(RegisterRequest("Relix", 200, "relix@example.com"))

        assertFalse(result.isValid)
        assertEquals(1, result.errors.size)
        assertEquals("age", result.errors[0].field)
        assertEquals("must be <= 150", result.errors[0].message)
    }

    @Test
    fun `multiple fields can fail at once`() {
        val result = validator.validate(RegisterRequest("", -5, "bad"))

        assertFalse(result.isValid)
        assertEquals(3, result.errors.size)
    }

    @Test
    fun `errors follow the field declaration order`() {
        val result = validator.validate(RegisterRequest("", -5, "bad"))

        assertEquals(listOf("name", "age", "email"), result.errors.map { it.field })
    }
}

五個測試都只針對 DSL 這個部分,規則本身第一輪測過了,錯誤怎麼收集第二輪測過了,這裡不重複

field name comes from the property reference 是這輪的核心,"name" 這個字串沒有出現在規則裡,是 RegisterRequest::name 自己帶出來的,an int field goes through its own constraints 確認 StringInt 兩種欄位都接得上,errors follow the field declaration order 定住多欄位的錯誤順序,三個欄位都不合格的時候,出來的順序跟 field() 寫下去的順序一樣

實作 DSL,FieldValidatorBuilder 與 validate

從這裡開始是給人寫規則用的那個部分,跟前面兩輪的核心型別分開,另外開一個 ValidationDsl.kt

Ktor 自己也是這樣切的,規則怎麼跑在 RequestValidation.kt,規則怎麼寫在 RequestValidationConfig.kt

class FieldValidatorBuilder<V> {
    internal val constraints = mutableListOf<Constraint<V>>()

    fun addConstraint(constraint: Constraint<V>) {
        constraints.add(constraint)
    }
}

fun FieldValidatorBuilder<String>.notBlank() {
    addConstraint(Constraints.notBlank())
}

fun FieldValidatorBuilder<String>.maxLength(max: Int) {
    addConstraint(Constraints.maxLength(max))
}

fun FieldValidatorBuilder<Int>.min(min: Int) {
    addConstraint(Constraints.min(min))
}

fun FieldValidatorBuilder<Int>.max(max: Int) {
    addConstraint(Constraints.max(max))
}

fun FieldValidatorBuilder<String>.matches(regex: Regex) {
    addConstraint(Constraints.matches(regex))
}

notBlank()FieldValidatorBuilder<String> 的 extension function,不是 FieldValidatorBuilder<Int> 的,所以你在 field(RegisterRequest::age) { } 裡面打不出 notBlank(),compiler 會直接擋下來

Unresolved reference. None of the following candidates is applicable
because of a receiver type mismatch:
fun FieldValidatorBuilder<String>.notBlank(): Unit

為什麼用 extension function 而不是 member function ? 因為 notBlank() 只對 String 有意義、min() 只對 Int 有意義,Extension function 可以對不同的泛型參數加不同的方法,member function 做不到 (你沒辦法在 class body 裡面根據 V 的具體型別定義不同的方法)

組裝出來的成品需要一個型別,這個 fun interface 是「什麼叫做一個 validator」的契約,跟 FieldValidator 是同一組東西,所以它不在 DSL 這邊,是寫進 Validation.kt

fun interface Validator<T> {
    fun validate(value: T): ValidationResult
}

接下來才是 DSL 的組裝入口,下面這段回到 ValidationDsl.kt,接在 extension function 後面

class ValidatorBuilder<T> {
    internal val fieldValidators = mutableListOf<FieldValidator<*>>()

    fun <V> field(
        property: kotlin.reflect.KProperty1<T, V>,
        block: FieldValidatorBuilder<V>.() -> Unit,
    ) {
        val builder = FieldValidatorBuilder<V>()
        builder.block()
        val fieldValidator = FieldValidator<V>(
            fieldName = property.name,
            getter = { target ->
                @Suppress("UNCHECKED_CAST")
                property.get(target as T)
            },
        )
        builder.constraints.forEach { fieldValidator.addConstraint(it) }
        fieldValidators.add(fieldValidator)
    }
}

fun <T> validate(block: ValidatorBuilder<T>.() -> Unit): Validator<T> {
    val builder = ValidatorBuilder<T>().apply(block)
    return Validator { value ->
        val errors = builder.fieldValidators.flatMap { it.validate(value as Any) }
        ValidationResult(errors)
    }
}

validate<T> { } 的 lambda receiver 是 ValidatorBuilder<T>,規則在建立 validator 時組裝一次,驗證每個 value 時只執行已建好的 constraints

property.name 自動拿到 "name""age""email" 這些字串,不用手動寫,改了 data class 的欄位名,compiler 會幫你抓到 property reference 斷掉的地方,flatMapfieldValidators 的順序把每個欄位的錯誤串起來,這就是上一節最後那個測試定住的順序

到這裡三輪的測試都通過了,驗證這一套本身已經完整,剩下的是把它接到 HTTP 上

TDD 寫 handler 整合測試

前面三輪都沒碰到 HTTP,unprocessableEntity() 還不存在,handler 也還沒寫,這輪要測的正是這段接線,plugin 有裝、路由走得到、body 進得來、422 有沒有真的出去,這條路徑要完整走一遍才知道,所以回到 TestKit,測試檔案放 ValidationIntegrationTest.kt

下面用到的 RegisterRequestemailRegex 沿用前面兩個測試檔的宣告,同一個 package 底下不用再寫一次

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ValidationIntegrationTest {

    private val userValidator = validate<RegisterRequest> {
        field(RegisterRequest::name) { notBlank(); maxLength(50) }
        field(RegisterRequest::age) { min(0); max(150) }
        field(RegisterRequest::email) { matches(emailRegex) }
    }

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.install(ContentNegotiation) { json() }
        app.routing {
            post("/users") {
                val req = receive<RegisterRequest>()
                val result = userValidator.validate(req)
                if (!result.isValid) return@post unprocessableEntity(result)
                created(req)
            }
        }
        return app
    }

    @Test
    fun `valid request returns 201`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """{"name": "Relix", "age": 25, "email": "relix@example.com"}""".toByteArray(),
        )

        assertEquals(201, response.statusCode)
    }

    @Test
    fun `invalid request returns 422 with errors`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """{"name": "", "age": -1, "email": "bad"}""".toByteArray(),
        )

        assertEquals(422, response.statusCode)
        val body = response.body.toString(Charsets.UTF_8)
        assertTrue(body.contains("Validation failed"))
        assertTrue(body.contains("name"))
        assertTrue(body.contains("age"))
        assertTrue(body.contains("email"))
    }

    @Test
    fun `bad json still returns 400 not 422`() {
        val testKit = RelixTestKit(createApp())
        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """not json""".toByteArray(),
        )

        assertEquals(400, response.statusCode)
    }
}

三個整合測試,合法 input → 201,非法欄位值 → 422 含結構化錯誤,JSON 本身壞掉 → 400 (receive<T>() 先攔,根本不會跑到 validation)

第三個測試很重要,它確認 400 和 422 的分界,receive<T>() 負責格式,validator 負責語意,兩邊各司其職

三個測試共用同一個 createApp(),裡面那行 install(ErrorHandling) 是第三個測試能成立的前提,receive<T>() 碰到解析不了的 body,是用 throw 表達的,丟出來的就是第 16 篇那個 RelixHttpException,外面沒有 middleware 接住,這個 exception 會穿過整條 pipeline 跑出來,測試拿到的不是 400,是直接失敗,第 21 篇就踩過同一個坑,400 這個 status code 是 ErrorHandling 把 exception 翻成 response 之後才存在的東西,少裝一個 plugin,就沒有 status code 可以斷言

實作 unprocessableEntity()

要讓上面三個測試通過,缺的是把 ValidationResult 變成 HTTP 回應的那一段,開一個 ValidationResponse.kt

import kotlinx.serialization.Serializable

@Serializable
data class ValidationErrorResponse(
    val status: Int,
    val message: String,
    val errors: List<FieldError>,
)

@Serializable
data class FieldError(val field: String, val message: String)

fun RelixCall.unprocessableEntity(result: ValidationResult): RelixResponse {
    val body = ValidationErrorResponse(
        status = 422,
        message = "Validation failed",
        errors = result.errors.map { FieldError(it.field, it.message) },
    )
    val json = kotlinx.serialization.json.Json.encodeToString(body)
    return RelixResponse(
        422,
        mapOf("Content-Type" to listOf("application/json; charset=utf-8")),
        json.toByteArray(),
    )
}

第 21 篇的 created() 是補進 RelixCall.kt 當成員的,這次的 unprocessableEntity() 改成 RelixCall 的 extension function、放在自己的檔案,差別在於它的參數是 ValidationResult,塞進 RelixCall.kt 就等於讓框架核心反過來認識 validation 這組型別

後面小結會說驗證結果是純資料、跟 HTTP 無關,檔案這樣切就是在守住這句話,相依的方向只能從 HTTP 這邊指向 validation,不能倒過來

錯誤回應長這樣

{
  "status": 422,
  "message": "Validation failed",
  "errors": [
    { "field": "name", "message": "must not be blank" },
    { "field": "age", "message": "must be >= 0" }
  ]
}

422 Unprocessable Entity 表示「server 看得懂你的 JSON,但內容不符合業務規則」,跟 400 Bad Request (JSON 格式壞掉) 不同層次

handler 那邊就是把 validator 叫出來用,一行判斷

post("/users") {
    val req = receive<RegisterRequest>()
    val result = userValidator.validate(req)
    if (!result.isValid) return@post unprocessableEntity(result)

    val user = userService.create(req.name, req.age, req.email)
    created(user)
}

四輪走完,整合測試也通過了

在 main 裡組起來跑一次

把 validator、ContentNegotiation 跟路由組進 main

@Serializable
data class RegisterRequest(val name: String, val age: Int, val email: String)

val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")

fun main() {
    val userValidator = validate<RegisterRequest> {
        field(RegisterRequest::name) { notBlank(); maxLength(50) }
        field(RegisterRequest::age) { min(0); max(150) }
        field(RegisterRequest::email) { matches(emailRegex) }
    }

    val app = RelixApplication()
    app.install(Logging) {
        logger = ConsoleLogger()
    }
    app.install(ErrorHandling)
    app.install(ContentNegotiation) {
        json()
    }

    app.routing {
        post("/users") {
            val req = receive<RegisterRequest>()
            val result = userValidator.validate(req)
            if (!result.isValid) return@post unprocessableEntity(result)

            created("user ${req.name} created")
        }
    }

    JdkHttpServerAdapter(app).start(8080)
}

plugin 的順序跟第 21 篇的 main 一致,ErrorHandling 排在 ContentNegotiation 前面,這樣 receive<RegisterRequest>() 拋出來的 RelixHttpException 才有人接,不然 JSON 壞掉的時候 client 等到的是斷掉的連線,不是 400

emailRegex 前面是宣告在 ConstraintTest.ktRegisterRequestFieldValidatorTest.kt,真的要跑起來的時候,這兩個都是正式程式碼,搬到 src/ 去,測試那邊的宣告刪掉,理由跟第 21 篇的 CreateUserRequest 一樣,兩邊各留一份雖然編譯得過,但不同步的時候很難查

合法的輸入

curl -i -X POST localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Cash","age":30,"email":"cash@example.com"}'
HTTP/1.1 201 Created
Content-type: text/plain; charset=utf-8
Content-length: 17

user Cash created

三個欄位都不合格

curl -i -X POST localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"","age":-1,"email":"not-an-email"}'
HTTP/1.1 422
Content-type: application/json; charset=utf-8
Content-length: 226

{"status":422,"message":"Validation failed","errors":[{"field":"name","message":"must not be blank"},{"field":"age","message":"must be >= 0"},{"field":"email","message":"must match pattern ^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"}]}

這一坨是實際吐出來的樣子,前面「實作 unprocessableEntity()」那節的 JSON 為了好讀有排版過,實際上 Json.encodeToString 預設不 pretty print,全部擠在一行

status line 也跟前面寫的不太一樣,上面只有一個數字 422,Unprocessable Entity 那串不見了,這是 JDK HttpServer 的行為,它內建一份 status code 對照表來補 reason phrase,查得到就填上去,查不到就留空,那份表涵蓋的是 RFC 2616 列的那一批,422 是後來 RFC 4918 才加進標準的,所以查不到,418、451 這些晚期才補進標準的 code 也一樣,對 client 沒有影響,HTTP 的語意本來就在數字上,reason phrase 只是給人看的那一段

三個錯誤一次全部回來,不是遇到第一個就停,這是 FieldValidator 把所有 constraint 跑完再收集的結果,對前端來說也比較好用,一次就能把三個欄位的紅字都標出來,不用來回三趟

另外看一下 email 那筆的訊息,整個 regex 被原封不動送到 client 了,這是 matches()regex.pattern 塞進訊息的後果,看起來雖然很好懂,但實際專案就要想一下,正規表達式對使用者沒有意義,也等於把內部的驗證規則攤開給外面看,要改的話,可能讓 matches() 多收一個自訂訊息的參數就好

JSON 本身壞掉

curl -i -X POST localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Cash","age":}'
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 123

Bad Request: Unexpected JSON token at offset 21: Expected numeric literal at path: $.age
JSON input: {"name":"Cash","age":}

這種情況根本走不到 validator,receive<RegisterRequest>() 解析就失敗了,這正是 422 跟 400 的分界,格式壞掉是 400,格式對但內容不合規則才是 422

body 那兩行是 ErrorHandlingRelixHttpException 翻成 response 之後帶出來的,kotlinx.serialization 連出錯的位置都寫進訊息了,offset 21、path $.age

log 這邊

[Relix] POST /users -> 201 (11ms)
[Relix] POST /users -> 422 (10ms)
[Relix] POST /users -> 400 (1ms)

三個 request 都有 log,連 JSON 壞掉的那個也在,因為它最後一樣正常回了一個 response 出去,對 Logging 來說跟前面兩個沒有兩樣

常見陷阱與設計取捨

為什麼 validator 在 handler 裡手動呼叫,不是自動觸發 ?

有些框架 (像 Spring 的 @Valid) 會在 controller 層自動觸發驗證,Relix 選擇手動呼叫,因為不是每個 endpoint 都有相同的驗證規則,POST /usersPUT /users/{id} 可能用不同的 validator (建立時 email 必填,更新時 email 可選),手動呼叫讓 handler 完全控制什麼時候驗證、驗證什麼

constraint 回字串 vs 回 enum ?

這裡用字串當錯誤訊息,簡單直接,用 enum 或 sealed class 可以做 i18n (根據 locale 切換語言),但多了一層間接,在這裡先不做,避免 over-engineering

為什麼所有錯誤一次收齊,不是遇到第一個就停 ?

一次只回一個錯誤的 API 很煩,client 修了一個,送出去又得到下一個,來回好幾趟,一次回傳所有錯誤,client 可以把表單上的問題一次顯示出來

為什麼工廠函式要放在 Constraints 這個 object 裡 ?

假設工廠函式是 top-level 的 fun notBlank(): Constraint<String>,那麼 fun FieldValidatorBuilder<String>.notBlank() 裡面那句 addConstraint(notBlank()) 呼叫到的並不是工廠函式,而是這個 extension function 自己,在 extension 的 receiver scope 裡面,掛在該 receiver 上的 extension 優先於 top-level 函式,名稱解析先找到誰就用誰,於是變成遞迴呼叫自己,extension 回傳 UnitaddConstraint() 要的是 Constraint<String>,型別對不上,compiler 在編譯期就擋下來,錯誤訊息是 Argument type mismatch: actual type is 'Unit', but 'Constraint<String>' was expected,五個 constraint 全部踩到同一個問題

如果兩邊型別剛好相容,這段程式碼會編譯成功,然後在執行期無限遞迴丟 StackOverflowError,那才是真的難查,解法是給工廠函式一個明確的命名空間,呼叫端寫 Constraints.notBlank(),就沒有歧義了

這件事跟前面「為什麼用 extension function 而不是 member function」是一體兩面,Extension function 讓我們能對 FieldValidatorBuilder<String>FieldValidatorBuilder<Int> 掛上不同的方法,同一套 receiver-based 的解析規則,換個角度看就是它在自己的 receiver scope 裡優先權比 top-level 函式高


小結

Validation DSL 用 property reference 綁定欄位,用 Constraint<V> 組合規則,用 extension function 對不同型別提供不同的 constraint 方法,驗證結果是純資料,跟 HTTP 無關,unprocessableEntity()ValidationResult 轉成 422 + JSON 回應,400 處理格式問題 (JSON 壞掉),422 處理語意問題 (欄位值不合法),兩邊分工明確


下一篇

下一篇加入 Authentication / Authorization,用 Plugin 安裝 Bearer 驗證,把 principal 放進 CallContext,提供 authenticate { } 保護路由


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 21 型別安全的 Request 讀取,receive<T>() 與 queryParam<T>()
下一篇
Kotlin 手刻 Ktor 從零開始 Day 23 Authentication / Authorization,認證與授權
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言